/*
* The MIT License (MIT)
*
* FXGL - JavaFX Game Library
*
* Copyright (c) 2015-2017 AlmasB (almaslvl@gmail.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.almasb.fxgl.scene;
import com.almasb.fxgl.app.ApplicationMode;
import com.almasb.fxgl.app.FXGL;
import com.almasb.fxgl.app.GameApplication;
import com.almasb.fxgl.core.logging.Logger;
import com.almasb.fxgl.gameplay.Achievement;
import com.almasb.fxgl.gameplay.GameDifficulty;
import com.almasb.fxgl.input.InputModifier;
import com.almasb.fxgl.input.Trigger;
import com.almasb.fxgl.input.UserAction;
import com.almasb.fxgl.saving.SaveFile;
import com.almasb.fxgl.scene.menu.MenuEventListener;
import com.almasb.fxgl.scene.menu.MenuType;
import com.almasb.fxgl.settings.SceneDimension;
import com.almasb.fxgl.ui.FXGLSpinner;
import javafx.beans.binding.Bindings;
import javafx.collections.FXCollections;
import javafx.event.Event;
import javafx.geometry.HPos;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.ScrollPane.ScrollBarPolicy;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import java.util.Arrays;
import java.util.function.Consumer;
import java.util.function.Supplier;
/**
* This is a base class for main/game menus. It provides several
* convenience methods for those who just want to extend an existing menu.
* It also allows for implementors to build menus from scratch. Freshly
* build menus can interact with FXGL by calling fire* methods.
*
* Both main and game menus <strong>should</strong> have the following items:
* <ul>
* <li>Background</li>
* <li>Title</li>
* <li>Version</li>
* <li>Profile name</li>
* <li>Menu Body</li>
* <li>Menu Content</li>
* </ul>
*
* However, in reality a menu can contain anything.
*
* @author Almas Baimagambetov (AlmasB) (almaslvl@gmail.com)
*/
public abstract class FXGLMenu extends FXGLScene {
/**
* The logger.
*/
protected static final Logger log = FXGL.getLogger("FXGL.Menu");
protected final GameApplication app;
protected final MenuType type;
protected MenuEventListener listener;
protected final Pane menuRoot = new Pane();
protected final Pane contentRoot = new Pane();
protected final MenuContent EMPTY = new MenuContent();
public FXGLMenu(GameApplication app, MenuType type) {
this.app = app;
this.type = type;
this.listener = app.getMenuListener();
getContentRoot().getChildren().addAll(
createBackground(app.getWidth(), app.getHeight()),
createTitleView(app.getSettings().getTitle()),
createVersionView(makeVersionString()),
menuRoot, contentRoot);
// we don't data-bind the name because menu subclasses
// might use some fancy UI without Text / Label
listener.profileNameProperty().addListener((o, oldName, newName) -> {
if (!oldName.isEmpty()) {
// remove last node which *should* be profile view
getContentRoot().getChildren().remove(getContentRoot().getChildren().size() - 1);
}
getContentRoot().getChildren().add(createProfileView("Profile: " + newName));
});
}
/**
* Switches current active menu body to given.
*
* @param menuBox parent node containing menu body
*/
protected void switchMenuTo(Node menuBox) {
// no default implementation
}
/**
* Switches current active content to given.
*
* @param content menu content
*/
protected void switchMenuContentTo(Node content) {
// no default implementation
}
protected abstract Button createActionButton(String name, Runnable action);
protected Button createContentButton(String name, Supplier<MenuContent> contentSupplier) {
return createActionButton(name, () -> switchMenuContentTo(contentSupplier.get()));
}
/**
* @return full version string
*/
private String makeVersionString() {
return "v" + app.getSettings().getVersion()
+ (app.getSettings().getApplicationMode() == ApplicationMode.RELEASE
? "" : "-" + app.getSettings().getApplicationMode());
}
/**
* Create menu background.
*
* @param width width of the app
* @param height height of the app
* @return menu background UI object
*/
protected abstract Node createBackground(double width, double height);
/**
* Create view for the app title.
*
* @param title app title
* @return UI object
*/
protected abstract Node createTitleView(String title);
/**
* Create view for version string.
*
* @param version version string
* @return UI object
*/
protected abstract Node createVersionView(String version);
/**
* Create view for profile name.
*
* @param profileName profile user name
* @return UI object
*/
protected abstract Node createProfileView(String profileName);
/**
* @return menu content containing list of save files and loadTask/delete buttons
*/
protected final MenuContent createContentLoad() {
log.debug("createContentLoad()");
ListView<SaveFile> list = new ListView<>();
list.setItems(listener.getSaveLoadManager().saveFiles());
list.prefHeightProperty().bind(Bindings.size(list.getItems()).multiply(36));
// this runs async
listener.getSaveLoadManager().querySaveFiles();
Button btnLoad = FXGL.getUIFactory().newButton("LOAD");
btnLoad.disableProperty().bind(list.getSelectionModel().selectedItemProperty().isNull());
btnLoad.setOnAction(e -> {
SaveFile saveFile = list.getSelectionModel().getSelectedItem();
fireLoad(saveFile);
});
Button btnDelete = FXGL.getUIFactory().newButton("DELETE");
btnDelete.disableProperty().bind(list.getSelectionModel().selectedItemProperty().isNull());
btnDelete.setOnAction(e -> {
SaveFile saveFile = list.getSelectionModel().getSelectedItem();
fireDelete(saveFile);
});
HBox hbox = new HBox(50, btnLoad, btnDelete);
hbox.setAlignment(Pos.CENTER);
return new MenuContent(list, hbox);
}
/**
* @return menu content with difficulty and playtime
*/
protected final MenuContent createContentGameplay() {
log.debug("createContentGameplay()");
Spinner<GameDifficulty> difficultySpinner =
new FXGLSpinner<>(FXCollections.observableArrayList(GameDifficulty.values()));
difficultySpinner.increment();
app.getGameState().gameDifficultyProperty().bind(difficultySpinner.valueProperty());
return new MenuContent(new HBox(25, FXGL.getUIFactory().newText("DIFFICULTY:"), difficultySpinner));
// FXGL.getUIFactory().newText("PLAYTIME: " + app.getMasterTimer().getPlaytimeHours() + "H "
// + app.getMasterTimer().getPlaytimeMinutes() + "M "
// + app.getMasterTimer().getPlaytimeSeconds() + "S"));
}
/**
* @return menu content containing input mappings (action -> key/mouse)
*/
protected final MenuContent createContentControls() {
log.debug("createContentControls()");
GridPane grid = new GridPane();
grid.setAlignment(Pos.CENTER);
grid.setHgap(50);
// row 0
grid.setUserData(0);
app.getInput().getBindings().forEach((action, trigger) -> addNewInputBinding(action, trigger, grid));
ScrollPane scroll = new ScrollPane(grid);
scroll.setVbarPolicy(ScrollBarPolicy.ALWAYS);
scroll.setMaxHeight(app.getHeight() / 2);
scroll.setStyle("-fx-background: black;");
HBox hbox = new HBox(scroll);
hbox.setAlignment(Pos.CENTER);
return new MenuContent(hbox);
}
private void addNewInputBinding(UserAction action, Trigger trigger, GridPane grid) {
Text actionName = FXGL.getUIFactory().newText(action.getName());
Button triggerName = FXGL.getUIFactory().newButton(trigger.toString());
triggerName.setOnMouseClicked(event -> {
Rectangle rect = new Rectangle(250, 100);
rect.setStroke(Color.AZURE);
Text text = FXGL.getUIFactory().newText("PRESS ANY KEY", 24);
Stage stage = new Stage(StageStyle.TRANSPARENT);
stage.initModality(Modality.WINDOW_MODAL);
stage.initOwner(getRoot().getScene().getWindow());
Scene scene = new Scene(new StackPane(rect, text));
scene.setOnKeyPressed(e -> {
// ignore illegal keys, however they may be part of a different event
// which is correctly processed further because code will be different
if (e.getCode() == KeyCode.CONTROL
|| e.getCode() == KeyCode.SHIFT
|| e.getCode() == KeyCode.ALT)
return;
boolean rebound = app.getInput().rebind(action, e.getCode(), InputModifier.from(e));
if (!rebound)
return;
triggerName.setText(app.getInput().getBindings().get(action).toString());
stage.close();
});
scene.setOnMouseClicked(e -> {
boolean rebound = app.getInput().rebind(action, e.getButton(), InputModifier.from(e));
if (!rebound)
return;
triggerName.setText(app.getInput().getBindings().get(action).toString());
stage.close();
});
stage.setScene(scene);
stage.show();
});
int controlsRow = (int) grid.getUserData();
grid.addRow(controlsRow++, actionName, triggerName);
grid.setUserData(controlsRow);
GridPane.setHalignment(actionName, HPos.RIGHT);
GridPane.setHalignment(triggerName, HPos.LEFT);
}
/**
* @return menu content with video settings
*/
protected final MenuContent createContentVideo() {
log.debug("createContentVideo()");
Spinner<SceneDimension> spinner =
new Spinner<>(FXCollections.observableArrayList(app.getDisplay().getSceneDimensions()));
Button btnApply = FXGL.getUIFactory().newButton("Apply");
btnApply.setOnAction(e -> {
SceneDimension dimension = spinner.getValue();
app.getDisplay().setSceneDimension(dimension);
});
return new MenuContent(new HBox(50, FXGL.getUIFactory().newText("Resolution"), spinner), btnApply);
}
/**
* @return menu content containing music and sound volume sliders
*/
protected final MenuContent createContentAudio() {
log.debug("createContentAudio()");
Slider sliderMusic = new Slider(0, 1, 1);
sliderMusic.valueProperty().bindBidirectional(app.getAudioPlayer().globalMusicVolumeProperty());
Text textMusic = FXGL.getUIFactory().newText("Music Volume: ");
Text percentMusic = FXGL.getUIFactory().newText("");
percentMusic.textProperty().bind(sliderMusic.valueProperty().multiply(100).asString("%.0f"));
Slider sliderSound = new Slider(0, 1, 1);
sliderSound.valueProperty().bindBidirectional(app.getAudioPlayer().globalSoundVolumeProperty());
Text textSound = FXGL.getUIFactory().newText("Sound Volume: ");
Text percentSound = FXGL.getUIFactory().newText("");
percentSound.textProperty().bind(sliderSound.valueProperty().multiply(100).asString("%.0f"));
HBox hboxMusic = new HBox(15, textMusic, sliderMusic, percentMusic);
HBox hboxSound = new HBox(15, textSound, sliderSound, percentSound);
hboxMusic.setAlignment(Pos.CENTER_RIGHT);
hboxSound.setAlignment(Pos.CENTER_RIGHT);
return new MenuContent(hboxMusic, hboxSound);
}
/**
* @return menu content containing a list of credits
*/
protected final MenuContent createContentCredits() {
log.debug("createContentCredits()");
ScrollPane pane = new ScrollPane();
pane.setPrefWidth(app.getWidth() * 3 / 5);
pane.setPrefHeight(app.getHeight() / 2);
pane.setStyle("-fx-background:black;");
VBox vbox = new VBox();
vbox.setAlignment(Pos.CENTER);
vbox.setPrefWidth(pane.getPrefWidth() - 15);
FXGL.getSettings()
.getCredits()
.getList()
.stream()
.map(FXGL.getUIFactory()::newText)
.forEach(vbox.getChildren()::add);
pane.setContent(vbox);
return new MenuContent(pane);
}
/**
* @return menu content containing feedback options
*/
protected final MenuContent createContentFeedback() {
log.debug("createContentFeedback()");
// url is a string key defined in system.properties
Consumer<String> openBrowser = url -> {
FXGL.getNet()
.openBrowserTask(FXGL.getString(url))
.onFailure(error -> log.warning("Error opening browser: " + error))
.execute();
};
Button btnGoogle = new Button("Google Forms");
btnGoogle.setOnAction(e -> openBrowser.accept("url.googleforms"));
Button btnSurveyMonkey = new Button("Survey Monkey");
btnSurveyMonkey.setOnAction(e -> openBrowser.accept("url.surveymonkey"));
VBox vbox = new VBox(15,
FXGL.getUIFactory().newText("Choose your feedback method", Color.WHEAT, 18),
btnGoogle,
btnSurveyMonkey);
vbox.setAlignment(Pos.CENTER);
return new MenuContent(vbox);
}
/**
* @return menu content containing a list of achievements
*/
protected final MenuContent createContentAchievements() {
log.debug("createContentAchievements()");
MenuContent content = new MenuContent();
for (Achievement a : app.getAchievementManager().getAchievements()) {
CheckBox checkBox = new CheckBox();
checkBox.setDisable(true);
checkBox.selectedProperty().bind(a.achievedProperty());
Text text = FXGL.getUIFactory().newText(a.getName());
Tooltip.install(text, new Tooltip(a.getDescription()));
HBox box = new HBox(25, text, checkBox);
box.setAlignment(Pos.CENTER_RIGHT);
content.getChildren().add(box);
}
return content;
}
/**
* A generic vertical box container for menu content
* where each element is followed by a separator.
*/
protected static class MenuContent extends VBox {
public MenuContent(Node... items) {
if (items.length > 0) {
int maxW = Arrays.stream(items)
.mapToInt(n -> (int) n.getLayoutBounds().getWidth())
.max()
.orElse(0);
getChildren().add(createSeparator(maxW));
for (Node item : items) {
getChildren().addAll(item, createSeparator(maxW));
}
}
sceneProperty().addListener((o, oldScene, newScene) -> {
if (newScene != null) {
onOpen();
} else {
onClose();
}
});
}
private Line createSeparator(int width) {
Line sep = new Line();
sep.setEndX(width);
sep.setStroke(Color.DARKGREY);
return sep;
}
private Runnable onOpen = null;
private Runnable onClose = null;
/**
* Set on open handler.
*
* @param onOpenAction method to be called when content opens
*/
public void setOnOpen(Runnable onOpenAction) {
this.onOpen = onOpenAction;
}
/**
* Set on close handler.
*
* @param onCloseAction method to be called when content closes
*/
public void setOnClose(Runnable onCloseAction) {
this.onClose = onCloseAction;
}
private void onOpen() {
if (onOpen != null)
onOpen.run();
}
private void onClose() {
if (onClose != null)
onClose.run();
}
}
/**
* Adds a UI node.
*
* @param node the node to add
*/
protected final void addUINode(Node node) {
getContentRoot().getChildren().add(node);
}
/**
* Can only be fired from main menu.
* Starts new game.
*/
protected final void fireNewGame() {
log.debug("fireNewGame()");
listener.onNewGame();
}
/**
* Loads the game state from last modified save file.
*/
protected final void fireContinue() {
log.debug("fireContinue()");
listener.onContinue();
}
/**
* Loads the game state from previously saved file.
*
* @param fileName name of the saved file
*/
protected final void fireLoad(SaveFile fileName) {
log.debug("fireLoad()");
listener.onLoad(fileName);
}
/**
* Can only be fired from game menu.
* Saves current state of the game with given file name.
*/
protected final void fireSave() {
log.debug("fireSave()");
listener.onSave();
}
/**
* @param fileName name of the save file
*/
protected final void fireDelete(SaveFile fileName) {
log.debug("fireDelete()");
listener.onDelete(fileName);
}
/**
* Can only be fired from game menu.
* Will close the menu and unpause the game.
*/
protected final void fireResume() {
log.debug("fireResume()");
listener.onResume();
}
/**
* Can only be fired from main menu.
* Logs out the user profile.
*/
protected final void fireLogout() {
log.debug("fireLogout()");
switchMenuContentTo(EMPTY);
listener.onLogout();
}
/**
* Call multiplayer access in main menu.
* Currently not supported.
*/
protected final void fireMultiplayer() {
log.debug("fireMultiplayer()");
listener.onMultiplayer();
}
/**
* App will clean up the world/the scene and exit.
*/
protected final void fireExit() {
log.debug("fireExit()");
listener.onExit();
}
/**
* App will clean up the world/the scene and enter main menu.
*/
protected final void fireExitToMainMenu() {
log.debug("fireExitToMainMenu()");
listener.onExitToMainMenu();
}
}